热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

都会|可能会_C#RabbitMQ入门指南

篇首语:本文由编程笔记#小编为大家整理,主要介绍了C#RabbitMQ入门指南相关的知识,希望对你有一定的参考价值。 文章目录 1.简介2. 相关概念2.1 消息中间件2.2 消息中间件的作用2.3

篇首语:本文由编程笔记#小编为大家整理,主要介绍了C#RabbitMQ入门指南相关的知识,希望对你有一定的参考价值。



文章目录


  • 1.简介
  • 2. 相关概念
    • 2.1 消息中间件
    • 2.2 消息中间件的作用
    • 2.3 RabbitMQ中的一些概念
    • 2.4 RabbitMQ模型

  • 3. ※点对点模型
    • 3.1 轮询消费(自动ack)
    • 3.2 ※手动发送ACK与数据持久存储
    • 3.3 消费模式

  • 4. ※发布订阅模型
    • 4.1 交换机(Exchange)
    • 4.2 `fanout`交换机
    • 4.3 路由(Routing)
      • 4.3.1 `Direct`交换机

    • 4.4 主题(Topics)
      • 4.4.1 `Topic`交换机
      • 4.4.2 最终实现


  • 5. 实现远程过程调用(RPC)


1.简介

RabbitMQ 是采用 erlang 语言实现 AMQP (Advanced Message Queuing Protocol ,高级消息
队列协议)的消息中间件,它最初起源于金融系统,用于在分布式系统中存储转发消息.

RabbitMQ 是目前非常热门的一款消息中间件,不管是互联网行业还是传统行业都在大量
地使用 RabbitMQ 凭借其高可靠、易扩展、高可用及丰富的功能特性受到越来越多企业的青睐。

RabbitMQ的具体特点可以概括为以下几点。


  • 可靠性:RabbitMQ使用一些机制来保证可靠性。如持久化、传输确认及发布确认等。
  • 灵活的路由:在消息进入队列之前,通过交换机来路由消息。对于典型的路由功能,提供了一些内置的交换机来实现。针对更复杂的路由功能,可以将多个交换机绑定在一起,可以通过插件机制来实现自己的交换机。
  • 扩展性:多个MQ节点可以组成一个集群,也可以根据实际业务情况动态地扩展
    集群中节点。
  • 高可用性:队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队
    列仍然可用。
  • 多种协议:除了原生支持AMQP协议,还支持STOMP、MQTT等多种消息
    中间件协议。
  • 多语言客户端:几乎支持所有常用语言,比如C#、Java、Python、Ruby、phpJavascript等。
  • 管理界面:RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集
    群中的节点等。
  • 插件机制: RabbitMQ 提供了许多插件 以实现从多方面进行扩展,当然也可以编写自
    己的插件。

依赖


  • 本文基于发稿时RabbitMQ的最新版本:3.8.19.

  • RabbitMQ客户端使用:RabbitMQ.Client 6.2.2

  • RabbitMQ可视化管理插件安装:官网。首先执行rabbitmq-plugins enable rabbitmq_management命令,然后打开管理面板:http://localhost:15672/#/ 即可,默认用户名密码都是guest。


2. 相关概念

2.1 消息中间件

消息 (Message):是指在应用间传送的数据。消息可以非常简单,比如只包含文本字符串、JSON 等,也可以很复杂,比如内嵌对象。

消息队列中间件 (Message Queue Middleware,简称为 MQ) 是指利用高效可靠的消息传递机制进行与平台无关的数据交流,并基于数据通信来进行分布式系统的集成。通过提供消息传和消息排队模型,它可以在分布式环境下扩展进程间的通信。它一般有两种传递模式:点对点(P2P, Point-to-Point) 模式和发布/订阅 (Pub/Sub) 模式。

点对点模式是基于队列的,消息生产发送消息到队列,消息消费者从队列中接收消息,队列的存在使得消息的异步传输成为可能。

发布订阅模式定义了如何向一个内容节点发布和订阅消息,这个内容节点称为主题 (topic) ,主题可以认为是消息传递的中介,消息发布者将消息发布到某个主题,而消息订阅者则从主题中订阅消息。主题使得消息的订阅者与消息的发布者互相保持独立,不需要进行接触即可保证消息的传递,发布/订阅模式在消息的一对多广播时采用。


2.2 消息中间件的作用


  • 解耦: 最大的作用其实是解耦。
  • 冗余存储:有些情况下,处理数据的过程会失败。消息中间件可以把数据进行持久化直
    到它们已经被完全处理,通过这一方式规避了数据丢失风险。在把一个消息从消息中间件中删除之前,需要你的处理系统明确地指出该消息己经被处理完成,从而确保你的数据被安全地保存直到你使用完毕。
  • 扩展性: 因为消息中间件解耦了应用的处理过程,所以提高消息入队和处理的效率是很容易的,只要另外增加处理过程即可,不需要改变代码,也不需要调节参数。
  • 削峰: 在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流 并不常
    见。如果以能处理这类峰值为标准而投入资源,无疑是巨大的浪费。使用消息中间件能够使关键组件支撑突发访问压力,不会因为突发的超负荷请求而完全崩惯
  • 可恢复性: 当系统一部分组件失效时,不会影响到整个系统。消息中间件降低了进程间的耦合度,所以即使一个处理消息的进程挂掉,加入消息中间件中的消息仍然可以在系统恢复后进行处理
  • 顺序保证: 在大多数使用场景下,数据处理的顺序很重要,大部分消息中间件支持一定程度上的顺序性。
  • 缓冲: 在任何重要的系统中,都会存在需要不同处理时间的元素。消息中间件通过一个缓冲层来帮助任务最高效率地执行,写入消息中间件的处理会尽可能快速。该缓冲层有助于控制和优化数据流经过系统的速度。
  • 异步通信: 在很多时候应用不想也不需要立即处理消息 消息中间件提供了异步处理机制,允许应用把 些消息放入消息中间件中,但并不立即处理它,在之后需要的时候再慢慢处理。

2.3 RabbitMQ中的一些概念

RabbitMQ的整体模型架构如下:


  1. Producer:生产者,用来生产消息。并把消息发给交换机(生产者不会把消息直接发给某个队列,很多图你可能会看到生产者直连队列,其实中间隐藏了一个默认的交换机)。生产者也就是发送消息的一方。

  2. Consumer:消费者,用来消费队列里的消息。也就是接受消息的一方。

  3. Exchange:交换机,有些文章会成为交换器。其实这个东西的作用更像是路由器。交换机会根据生产者发过来的消息的routingKey,把消息丢到不同的队列中。

  4. Queue:队列,用来存储交换机丢过来的消息(可以理解为邮箱)。一个队列可以被多个消费者进行消费,此时队列里的消息会按照轮询的方式一个个的分配给下面的消费者(不支持队列层面的广播消费)。

  5. channel: 通道,RabbitMQ 处理的每条 AMQP 指令都是通过通道完成的。如下图所示。通道的存在其实就是为了复用TCP连接,本质上我们也可以使用TCP连接发送命令。但是当应用中有多个线程需要生产或者消费时,就需要创建多个TCP连接,而TCP连接的创建和销毁很费资源。

  6. routingKey:路由键,交换机根据这个的值来决定把消息丢到哪个队列里,没有队列可以接受的话,可能把消息返回给生产者也可能直接丢弃。

  7. Broker:RabbitMQ的服务节点或服务实例。可以简单里的理解为就是一台RabbitMQ服务器。

  8. Binding:绑定,消费者端就行配置,建立队列与某个交换机的关系,这样交换机收到消息之后就知道是否要投递到这个队列了。


2.4 RabbitMQ模型

可以看到官网的教程里有六种模型:

看起来很多很唬人,但是不要怕,本质上也就以下两种,学起来也很快。


  1. 点对点:前两种就是属于点对点模型,即队列里的一个消息只能被一个消费者消费。第二种是对第一种的扩充,额外增加了一个消费者而已。多个消费者就是采用轮询的机制去消费同一个队列里的消息。
  2. 发布订阅:剩下的4种都是发布订阅模型,即生产者发布的一个消息可以被N个消费者消费,实现方式是通过交换机把同一个消息投递到了N个队列里。4、5、6都是对3的功能扩充,让你有更大的自由度来决定一个消息能投递到哪个队列里。

3. ※点对点模型

队列的一个消息只能被一个消费者消费,多个消费者可以通过轮询的方式消费也可以通过手动响应ack的方式竞争消费。


3.1 轮询消费(自动ack)

当开启一个消费者实例时模型如下:

当开启两个消费者实例时模型如下:

消费者示例:
channel.BasicConsume的第二个参数为true,表示当消费者收到消息后(非消息的业务逻辑处理完后),会自动发送一个ack给mq表示消息已收到。

static void Main(string[] args)

var factory = new ConnectionFactory() HostName = "localhost" ;
using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

channel.QueueDeclare("hello", false, false, false, null);
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, args) =>

byte[] body = args.Body.ToArray();
var msg = Encoding.UTF8.GetString(body);
Console.WriteLine(" [x] Received 0", msg);
;
//第二个参数autoAck为true
channel.BasicConsume("hello", true, consumer);
Console.WriteLine("Press [Enter] to exit");
Console.ReadKey();




生产者示例:

static void Main(string[] args)

var factory = new ConnectionFactory() HostName = "localhost" ;
using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

//声明队列操作是幂等的,当队列不存在时,会进行创建。
channel.QueueDeclare(queue: "hello", durable: false, exclusive: false, autoDelete: false, arguments: null);
Console.WriteLine("请输入要发送的消息内容:");
string msg = null;
while (!string.IsNullOrEmpty(msg = Console.ReadLine()))

var body = Encoding.UTF8.GetBytes(msg);
//body是byte类型,使用了一个名为“”的默认交换机
channel.BasicPublish(exchange: "", routingKey: "hello", basicProperties: null, body: body);
Console.WriteLine("[x] Sent 0", msg);




Console.WriteLine("Press [Enter] to exit");
Console.Read();

当开启多个消费者之后,默认会轮询消费。


3.2 ※手动发送ACK与数据持久存储

当消费者消费完成一个消息之后,手动发送一条ack命令给broker。解决consumer突然死掉之后,导致消息丢失的问题。如果mq一直没收到ack,则会将此消息重新入队列,给其他消费者进行消费。

生产者示例:


  • task_queue的durable设置为true,这样及时Broker重启,此队列也不会消失。
  • 将消息的Persistent也设置为true。即消息也持久存储,但是并不代表消息会100%不丢失,它只是告诉MQ将消息存储在硬盘上。在MQ收到消息且写入硬盘之前如果挂了,那消息就丢了。如果需要保证100%的可用,可以使用后面小节的“发布确认”功能。

static void Main(string[] args)

var factory = new ConnectionFactory() HostName = "localhost" ;
using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

//durable设置为了true,队列持久化
channel.QueueDeclare(queue: "task_quene", durable: true, exclusive: false, autoDelete: false, arguments: null);

var props = channel.CreateBasicProperties();
//消息持久化
props.Persistent = true;
Console.WriteLine("请输入要发送的消息内容:");
string msg = null;
while (!string.IsNullOrEmpty(msg = Console.ReadLine()))

var body = Encoding.UTF8.GetBytes(msg);
channel.BasicPublish(exchange: "", routingKey: "task_quene", basicProperties: props, body: body);
Console.WriteLine("[x] Sent 0", msg);



Console.WriteLine("Press [Enter] to exit");
Console.Read();

消费者示例:

默认情况下MQ会按照worker的顺序把队列里的消息一个个的分给worker,这种分配消息的方式有一定的弊端,假如有两个worker且队列里的消息根据耗时长短间隔排列。这样所有耗时长的消息都会被分给worker1,短的分配给worker2. 造成worker2长时间空闲。所以就可以通过设置Qos的方式来改善,channel.BasicQos(0, 1, false)表示broker一次只把1个消息发给worker,直到这个worker发出了ack,才继续把下一个消息分给他。

static void Main(string[] args)

var factory = new ConnectionFactory() HostName = "localhost" ;
using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

channel.QueueDeclare("task_quene", true, false, false, null);
//设置qos
channel.BasicQos(0, 1, false);
Console.WriteLine(" [*] Waiting for messages.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received +=async (model, args) =>

byte[] body = args.Body.ToArray();
var msg = Encoding.UTF8.GetString(body);
Console.WriteLine($"[-] Task msg received");
await Task.Delay(msg.Length * 1000);//模拟耗时任务
Console.WriteLine(" [x] Task 0 Done", msg);
//手动发送ack,必须在同一个channel里发送
channel.BasicAck(args.DeliveryTag, false);
;
channel.BasicConsume("task_quene", false, consumer);
Console.WriteLine("Press [Enter] to exit");
Console.ReadKey();




3.3 消费模式

消费者消费消息有两种模式:


  1. 推(push):服务端主动推送消息到channel里,然后消费者消费信道里的消息
  2. 拉(pull):消费者手动从服务端拉去消息

在上面的例子中我们看到的其实就是模式,使用的是channel.BasicConsume方法。而模式需要使用channel.BasicGet方法。如:

var response=channel.BasicGet("task_quene",autoAck:false);
var body=response.Body;
channel.BasicAck(response.Envelope.DeliveryTag,false);


注意:
BasicGet一次只能获取一条消息,且不能将其放到一个循环里来替代BasicComsume,否则会严重影响RabbitMQ的性能。如果要实现高吞吐量,则应该使用BasicConsume。



4. ※发布订阅模型

一个消息可以被多个消费者消费,此时就用到了交换机。


4.1 交换机(Exchange)

回顾下我们之前的例子:


  • 一个生产者用来发送消息
  • 一个队列用来缓存和存储这些消息
  • 一个消费者用来接收消息

RabbitMQ消息模型的设计核心思想是:生产者从来不把消息直接丢给队列,它甚至都不知道要把消息丢给哪个队列。
取而代之的是生产者只需要把消息丢给交换机(exchange)。交换机决定把消息丢给哪个队列,或者丢给哪些队列,或者丢弃这个消息。

下图就是发布订阅的模型:

交换机分为以下几种类型:


  • direct:把消息路由到与RoutingKey完全匹配的队列中
  • topic:把消息路由到符合RoutingKey匹配规则的队列中
  • headers:不依赖路由键匹配规则路由消息。是根据发送消息内容中的headers属性进行匹配。性能差,基本用不到。
  • fanout:把所有发送到该交换机的消息路由到所有与该交换机绑定的队列中

4.2 fanout交换机

1. 声明一个临时队列和一个交换机

消费者需要声明一个临时队列,这个临时队列只能是消费者声明。当消费者断开连接时,这个队列将会被删除。(此场景适用于我们的logs接收测试,因为消费者不关系之前的日志是什么)。临时队列的名称类似于amq.gen-JzTY20BRgKO-HjmUJj0wLg格式。

消费者或者生产者也要声明一个交换机。

//创建临时队列(只能是消费者)
var quenuName = channel.QueueDeclare().QueueName;
//创建交换机(生产者或消费者)
channel.ExchangeDeclare("logs", ExchangeType.Fanout);

2. 将交换机与队列绑定

消费者需要将临时队列与交换机进行绑定。

channel.QueueBind(queue:quenuName, exchange:"logs",routingKey:"");

3. 最终模型与代码

与之前例子最大的不同是,此时生产者需要把消息发送到交换机而不是某个队列上。在发送时我们就需要提供一个routingKey,但是fanout模式的交换机会忽略这个参数。

这样当我们发送消息时,与exchange关联的所有队列都可以收到这个消息。

生产者:

static void Main(string[] args)

var factory = new ConnectionFactory() HostName = "localhost" ;
using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

channel.ExchangeDeclare("logs", ExchangeType.Fanout);
Console.WriteLine("请输入要发送的消息内容:");
string msg = null;
while (!string.IsNullOrEmpty(msg = Console.ReadLine()))

var body = Encoding.UTF8.GetBytes(msg);
channel.BasicPublish(exchange: "logs", routingKey: "", basicProperties: null, body: body);
Console.WriteLine("[x] Sent 0", msg);



Console.WriteLine("Press [Enter] to exit");
Console.Read();

消费者:

这里我们使用channel.QueueDeclare().QueueName创建一个临时队列并返回队列名称。当消费者断开连接时,这个队列将会被删除。(此场景试用与我们的logs接收,因为消费者不关心之前的日志是什么)

static void Main(string[] args)

var factory = new ConnectionFactory() HostName = "localhost" ;
using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

channel.ExchangeDeclare("logs", ExchangeType.Fanout);
//创建一个临时队列
var queueName = channel.QueueDeclare().QueueName;
//交换机与队列的绑定
channel.QueueBind(queue:queueName, exchange:"logs",routingKey: "");
Console.WriteLine(" [*] Waiting for messages.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, args) =>

byte[] body = args.Body.ToArray();
var msg = Encoding.UTF8.GetString(body);
Console.WriteLine($"[-] Task msg received");
//手动发送ack,必须在同一个channel里发送
channel.BasicAck(args.DeliveryTag, false);
;
channel.BasicConsume(queueName, false, consumer);
Console.WriteLine("Press [Enter] to exit");
Console.ReadKey();




4.3 路由(Routing)

在上面的打印log例子中,所有的消费者都能收到同一个log消息。在这一节我们将通过路由的方式来订阅消息的子集。例如一个消费者只用来接收critical级别的消息,而其他消费者接收所有消息。

在上一小节中,消费者将队列与交换机绑定时用到了channel.QueueBind方法,表示“这个队列对这个交换机发出消息很感兴趣,愿意接收这些消息”。这个绑定方法还接收一个routingKey参数,取决于不同的交换机类型,这个参数有可能会被忽略(例如我们之前用到的fanout交换机)。


4.3.1 Direct交换机

所以这里我们将以direct类型的交换机为例,如果绑定队列时设置的routingKey等于发送消息时设置的routingKey,这个队列就可以收到消息。举例如下:

direct类型的交换机X下绑定了两个队列Q1和Q2。Q1的routingKey是orange,Q2的routingKey是blackgreen。所以发送时如果消息的routingKey设置为orange则Q1会接收到,如果是blackgreen则Q2会接收到,如果是其他的值,则会被交换

推荐阅读
  • 从单机存储进化为接口和存储的分离概述接口服务层对外提供REST服务,数据服务层提供数据存储功能。两者之间通过消息队列进行通信,数据服务层的所有数据服 ... [详细]
  • ZeroMQ在云计算环境下的高效消息传递库第四章学习心得
    本章节深入探讨了ZeroMQ在云计算环境中的高效消息传递机制,涵盖客户端请求-响应模式、最近最少使用(LRU)队列、心跳检测、面向服务的队列、基于磁盘的离线队列以及主从备份服务等关键技术。此外,还介绍了无中间件的请求-响应架构,强调了这些技术在提升系统性能和可靠性方面的应用价值。个人理解方面,ZeroMQ通过这些机制有效解决了分布式系统中常见的通信延迟和数据一致性问题。 ... [详细]
  • 在ElasticStack日志监控系统中,Logstash编码插件自5.0版本起进行了重大改进。插件被独立拆分为gem包,每个插件可以单独进行更新和维护,无需依赖Logstash的整体升级。这不仅提高了系统的灵活性和可维护性,还简化了插件的管理和部署过程。本文将详细介绍这些编码插件的功能、配置方法,并通过实际生产环境中的应用案例,展示其在日志处理和监控中的高效性和可靠性。 ... [详细]
  • 利用ZFS和Gluster实现分布式存储系统的高效迁移与应用
    本文探讨了在Ubuntu 18.04系统中利用ZFS和Gluster文件系统实现分布式存储系统的高效迁移与应用。通过详细的技术分析和实践案例,展示了这两种文件系统在数据迁移、高可用性和性能优化方面的优势,为分布式存储系统的部署和管理提供了宝贵的参考。 ... [详细]
  • 在开发过程中,我最初也依赖于功能全面但操作繁琐的集成开发环境(IDE),如Borland Delphi 和 Microsoft Visual Studio。然而,随着对高效开发的追求,我逐渐转向了更加轻量级和灵活的工具组合。通过 CLIfe,我构建了一个高度定制化的开发环境,不仅提高了代码编写效率,还简化了项目管理流程。这一配置结合了多种强大的命令行工具和插件,使我在日常开发中能够更加得心应手。 ... [详细]
  • 如何精通编程语言:全面指南与实用技巧
    如何精通编程语言:全面指南与实用技巧 ... [详细]
  • 本文推荐了六款高效的Java Web应用开发工具,并详细介绍了它们的实用功能。其中,分布式敏捷开发系统架构“zheng”项目,基于Spring、Spring MVC和MyBatis技术栈,提供了完整的分布式敏捷开发解决方案,支持快速构建高性能的企业级应用。此外,该工具还集成了多种中间件和服务,进一步提升了开发效率和系统的可维护性。 ... [详细]
  • 当前,众多初创企业对全栈工程师的需求日益增长,但市场中却存在大量所谓的“伪全栈工程师”,尤其是那些仅掌握了Node.js技能的前端开发人员。本文旨在深入探讨全栈工程师在现代技术生态中的真实角色与价值,澄清对这一角色的误解,并强调真正的全栈工程师应具备全面的技术栈和综合解决问题的能力。 ... [详细]
  • 修复一个 Bug 竟耗时两天?真的有那么复杂吗?
    修复一个 Bug 竟然耗费了两天时间?这背后究竟隐藏着怎样的复杂性?本文将深入探讨这个看似简单的 Bug 为何会如此棘手,从代码层面剖析问题根源,并分享解决过程中遇到的技术挑战和心得。 ... [详细]
  • 本文详细介绍了HDFS的基础知识及其数据读写机制。首先,文章阐述了HDFS的架构,包括其核心组件及其角色和功能。特别地,对NameNode进行了深入解析,指出其主要负责在内存中存储元数据、目录结构以及文件块的映射关系,并通过持久化方案确保数据的可靠性和高可用性。此外,还探讨了DataNode的角色及其在数据存储和读取过程中的关键作用。 ... [详细]
  • 本文提供了 RabbitMQ 3.7 的快速上手指南,详细介绍了环境搭建、生产者和消费者的配置与使用。通过官方教程的指引,读者可以轻松完成初步测试和实践,快速掌握 RabbitMQ 的核心功能和基本操作。 ... [详细]
  • 在RabbitMQ中,消息发布者默认情况下不会接收到关于消息在Broker中状态的反馈,这可能导致消息丢失的问题。为了确保消息的可靠传输与投递,可以采用确认机制(如发布确认和事务模式)来验证消息是否成功抵达Broker,并采取相应的重试策略以提高系统的可靠性。此外,还可以配置消息持久化和镜像队列等高级功能,进一步增强消息的可靠性和高可用性。 ... [详细]
  • SWIG 3.0.12 Windows官方版下载:实现C语言与PHP、Java、Python等多语言代码互调接口
    SWIG 3.0.12 Windows官方版是一款强大的接口生成工具,能够实现C语言与多种高级编程语言(如Java、C#)及脚本语言(如PHP、JavaScript、Python)之间的互操作性。它不仅支持跨语言调用,还提供了丰富的封装选项,确保了代码的高效性和可维护性。 ... [详细]
  • JVM上高性能数据格式库包Apache Arrow入门和架构的示例分析
    小编给大家分享一下JVM上高性能数据格式库包ApacheArrow入门和架构的示例分析,希望大家阅读完这篇文章之后都有所收获,下面让我们一起去探讨吧!Apac ... [详细]
  • 分布式一致性算法:Paxos 的企业级实战
    一、简介首先我们这个平台是ES专题技术的分享平台,众所周知,ES是一个典型的分布式系统。在工作和学习中,我们可能都已经接触和学习过多种不同的分布式系统了,各 ... [详细]
author-avatar
混事珊远_692
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有